Skip to content

fix: make StepRegistry atomic and raise on corruption - #3922

Open
Quratulain-bilal wants to merge 10 commits into
github:mainfrom
Quratulain-bilal:fix/step-registry-atomic-save
Open

Quratulain-bilal wants to merge 10 commits into
github:mainfrom
Quratulain-bilal:fix/step-registry-atomic-save

Conversation

@Quratulain-bilal

Copy link
Copy Markdown
Contributor

Problem

StepRegistry._load() silently returned empty defaults on corruption, causing installed steps to appear to vanish. save() wrote directly to the target file risking data loss.

Fix

_load() now raises OSError on symlinked paths and corrupted files. save() now uses atomic writes via mkstemp + os.replace.

…nt data loss

1. _load() now raises OSError on symlinked paths and corrupted files
   instead of silently returning empty defaults (matches WorkflowRegistry)
2. save() now writes to a temp file and atomically replaces, preventing
   data loss from truncated writes on crash

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Makes StepRegistry fail closed on corruption and persist data atomically.

Changes:

  • Raises OSError for unsafe or corrupted registries.
  • Writes through a temporary file using os.replace.
  • Cleans up temporary files after failures.
Show a summary per file
File Description
src/specify_cli/workflows/catalog.py Hardens registry loading and saving.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 1/1 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/catalog.py Outdated
Comment thread src/specify_cli/workflows/catalog.py Outdated
Comment thread src/specify_cli/workflows/catalog.py Outdated

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback and fix test and lint errors

Verifies:
- StepRegistry raises OSError on symlinked registry files
- StepRegistry raises OSError on corrupted JSON
- StepRegistry.save() uses atomic write via mkstemp + os.replace

Addresses Copilot review feedback on github#3922.
…mic-save

Resolve conflicts by taking main's split of workflows/catalog.py: the
StepRegistry code in this branch belongs in workflows/step/catalog/_domain.py,
so main's workflows/catalog/_domain.py is taken as-is instead of carrying a
duplicate StepRegistry, and tests/test_workflows.py takes main's version with
the branch's new tests re-added on top.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
Port the branch's StepRegistry hardening to where main now keeps the class
(workflows/step/catalog/_domain.py): _load raises OSError for a symlinked
path, an unreadable file, unparseable JSON, or a non-registry shape instead
of silently returning an empty registry, and save writes through a
temp file plus os.replace so a crash cannot leave a half-written registry.

The new constructor errors would otherwise surface as tracebacks, and the
step commands used to reach their directory-symlink check only because the
registry swallowed the fault. A shared _load_step_registry_or_exit helper now
prints a clean error, and add/remove resolve the steps base directory first so
the symlinked-directory message keeps its specific wording.

Five tests that pinned the silent-reset behaviour are updated to the fail-loud
contract, and the branch's three regression tests (symlink, corrupted JSON,
atomic save) come along.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
…mic-save

Take main's refactor in the conflicted step files: the registry hardening in
this branch lives in workflows/step/catalog/_domain.py, while main has moved
the CLI-coupled logic into workflows/step/installer.py (resolve_steps_base_dir,
StepInstallError, the install transaction) and rewritten workflow_step_remove
around _remove_step_locked. tests/test_workflows.py merges with the branch's
registry tests kept on top of main's version.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
StepRegistry._load returned an empty registry for a symlinked path, an
unreadable file, unparseable JSON, or a non-registry shape, so corruption
reached callers as a silently reset registry. It now raises OSError with the
registry path in the message; save() already writes atomically on main.

Because the constructor now raises, translate the error where it surfaces:
installer._load_step_registry maps OSError onto StepInstallError so the
existing command handlers keep printing a clean error, and the new
_helpers._load_step_registry_or_exit does the same for the step commands.
workflow_step_remove resolves the steps base directory before loading the
registry so the symlinked-directory message keeps its specific wording.

Tests: the three fail-loud regression tests (symlinked file, corrupted JSON,
atomic save) are kept, and five tests that pinned the silent-reset behaviour
now assert the raised OSError instead.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/specify_cli/workflows/step/_helpers.py
Comment thread src/specify_cli/workflows/step/installer.py
Comment thread tests/test_workflows.py
Comment thread src/specify_cli/workflows/step/command_list.py Outdated
@mnriem

mnriem commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Addresses 4 Copilot findings:

1. test_cli_list_exits_cleanly_on_corrupted_registry: CLI produces
   exit code 1 with clean error, no traceback
2. test_installer_translates_oserror_before_mutation: OSError is
   translated to StepInstallError before any mutation
3. test_save_failure_preserves_previous_bytes: injected write failure
   leaves previous bytes intact (atomicity regression)
4. Fixed mojibake em dash in command_list.py comment

Assisted-by: GitHub Copilot (autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The installer regression test bypasses the changed installation paths and cannot verify that corruption is rejected before filesystem mutation.

1 open finding
4 resolved since last review

🧠 Review effort: Balanced

Comment thread tests/test_workflows.py Outdated
@mnriem

mnriem commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Replaces the direct _load_step_registry() call with a full
install_step_package() invocation using a valid package and a
corrupted registry, asserting no destination or staging directory
is created.

Assisted-by: GitHub Copilot (autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two regression assertions are overly broad or do not match the staging-directory naming convention.

1 open finding
1 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Assert save() raises the specific StepValidationError type

tests/​test_workflows.py:11489

This assertion accepts every Exception, so it cannot verify save()'s documented error contract and would still pass if an unrelated exception escaped. Since the write path wraps I/O failures as StepValidationError, assert that specific type.

🧠 Review effort: Balanced

Comment thread tests/test_workflows.py Outdated
@mnriem

mnriem commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

The glob tmp* does not match _WORK_DIR_PREFIX (.speckit-step-install-),
so a leaked staging directory would go unnoticed.

Assisted-by: GitHub Copilot (autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Bundle reference validation still suppresses registry corruption and can treat installed steps as absent.

0 open findings

1 resolved since last review
Previously missed (3)

In code that hasn't changed since last review

Medium severity Propagate registry corruption instead of treating it as not installed

src/​specify_cli/​workflows/​step/​catalog/​_domain.py:117

This still does not make corruption fail loudly for every reader: bundles/references.py:18-60 catches this OSError as a best-effort lookup failure and returns False. Consequently, bundle validate can still treat an installed step as absent (or merely emit an offline/catalog warning), reproducing the “steps appear to vanish” behavior described by this PR. Propagate or explicitly translate registry corruption in that caller instead of collapsing it into “not installed,” with a bundle-reference regression test.

Low severity Add remove regression test for corrupted registry

src/​specify_cli/​workflows/​step/​command_remove.py:26

Add a negative regression test for the remove command with a corrupted registry, asserting a clean exit and that the existing step directory remains untouched. This newly routed path precedes destructive removal, but only step list and installation are covered; the repository requires relevant prevented-behavior tests, and tests/specify_cli/workflows/step/test_command_remove.py is the mirrored public-command suite.

Low severity Assert save raises StepValidationError

tests/​test_workflows.py:11489

Exception subsumes OSError, so this assertion accepts any ordinary exception and does not verify save()'s public failure contract. Since save() wraps write failures as StepValidationError, assert that exact type so a regression that leaks an unrelated exception cannot pass.

🧠 Review effort: Balanced

@mnriem

mnriem commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Malformed step entries can still bypass validation and trigger tracebacks, and two changed command paths lack regression coverage.

3 open findings

🧠 Review effort: Balanced

Comment on lines +140 to +144
if not isinstance(data.get("steps"), dict):
raise OSError(
f"Step registry at {self.registry_path} is corrupted: "
"'steps' must be an object"
)
safe_step_id = cli._escape_markup(str(step_id))

registry = StepRegistry(project_root)
registry = step_helpers._load_step_registry_or_exit(project_root)
Comment on lines +25 to +26
steps_base_dir = step_helpers._resolve_steps_base_dir_or_exit(project_root)
registry = step_helpers._load_step_registry_or_exit(project_root)
@mnriem

mnriem commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants